<?php
require_once "ImifiedBot.php";
require_once "RtmApi.php";

class RtmBot extends ImifiedBot {
    /**
     * 
     * @var RtmApi
     */
    private $api = null;
    
    private $adminJidHash;
    
    public function __construct($apiKey, $apiSecret, $adminJidHash = null) {
        parent::__construct();
        
        $this->adminJidHash = $adminJidHash;
        
        $this->api = new RtmApi($apiKey, $apiSecret);
    }
    
    private function fromStorage($name, $default = null) {
        if (isset($this->storage[$name])) {
            return $this->storage[$name];
        }
        else {
            return $default;
        }
    }
    
    protected function step1() {
        $auth = $this->fromStorage("auth");
        if ($auth === null) {
            // authentication
            
            $frob = $this->api->getFrob();
            $this->storage["frob"] = $frob;
            $this->answer("Jabber2RTM\n\nFirst you need to grant access to RTM to me.\nPlease follow the URL: " . $this->api->getDesktopAuthUrl(RtmApi::PERMS_DELETE, $frob) . "\n\nThen say 'hey' to me");
        }
        else {
            $this->stepN();
        }
    }
    
    protected function step2() {
        $auth = $this->fromStorage("auth");
        if ($auth === null) {
            $frob = $this->fromStorage("frob");
            if ($frob !== null) {
                try {
                    $this->storage["auth"] = $this->api->getToken($frob);
                    $this->answerReset("Authenticated!\n\n" . $this->getHelpMessage());
                }
                catch (RtmApiException $e) {
                    $this->answerReset("Something went wrong. Please try to say something more");
                }
            }
            else {
                $this->answerReset("No frob found. Please try to say something more");
            }
        }
        else {
            $this->stepN();
        }
    }
    
    protected function stepN() {
        $this->api->setAuthToken($this->storage["auth"]["token"]);
        $this->api->beginTimeline();
        
        // process command
        $this->answerReset($this->processCommand());
    }
    
    private function processCommand() {
        try {
            $message = $this->getMessage();
            $message = trim($message);
            
            // check for commands
            if ($message == "HELP") {
                $this->clearTaskContext();
                
                return $this->getHelpMessage();
            }
            elseif ($message == "\$SESSION") {
                $this->needAdmin();
                
                return print_r($this->storage->getAll(), true);
            }
            elseif ($message == "\$USERS") {
                $this->needAdmin();
                
                $imifiedApi = new ImifiedApi(Config::$imifiedBotId, Config::$imifiedLogin, Config::$imifiedPassword);
                return print_r($imifiedApi->getAllUsers("Jabber"), true);
            }
            elseif (preg_match("`^\\\$SENDTOALL\s+(.+)$`s", $message, $aMatches)) { 
                $this->needAdmin();
                
                $imifiedApi = new ImifiedApi(Config::$imifiedBotId, Config::$imifiedLogin, Config::$imifiedPassword);
                $aUsers = $imifiedApi->getAllUsers("Jabber");
                $count = 0;
                foreach ($aUsers as $oUser) {
                    $imifiedApi->sendMessage($aMatches[1], $oUser->userkey);
                    $count++;
                }
                
                return "Message sent to $count users";
            }
            elseif ($message == "COMFIRMATION") {
                $this->clearTaskContext();
                
                return $this->commandComfirmation();
            }
            elseif (preg_match("`^(?:(?:L(?:IST)?)|\\?)(\s+.+)?$`", $message, $aMatches)) {
                $query = isset($aMatches[1]) ? trim($aMatches[1]) : "";
                return $this->commandList($this->createFilterString($query));
            }
            elseif (($result = $this->parseCommandWithTaskId($message)) !== false) {
                return $result;
            }
            elseif ($message != "") {
                $this->clearTaskContext();
                
                // parse message as '<name>\n<note>'
                if (preg_match("`^([^\n]+)\n(.+)$`s", $message, $aMatches)) {
                    $message = $aMatches[1];
                    $note    = $aMatches[2];
                }
                else {
                    $note = null;
                }
                
                return $this->commandAddTask($message, $note);
            }
        }
        catch (RtmBotUserError $e) {
            return "ERROR: " . $e->getMessage();
        }
    }
    
    private function parseCommandWithTaskId($message) {
        $aCommands = array (
            "commandCompleteTask" => array ("C", "COMPLETE", "+"),
            "commandDeleteTask" => array ("D", "DELETE", "-"),
            "commandPostponeTask" => array ("P", "POSTPONE", ">"), 
            "!commandAddTagsToTask" => array ("T", "TAGS", "#"),
            "!commandRemoveTagsFromTask" => array ("-T", "-TAGS", "-#"),
        );
        
        foreach ($aCommands as $method => $aAliases) {
            $moreParams = preg_match("`^!`", $method);
            $method = preg_replace("`^!`", "", $method);
            
            $rAliases = array ();
            foreach ($aAliases as $alias) {
                $rAliases[] = "(?:" . preg_replace("`\#`", "\\\\#", preg_quote($alias, "`")) . ")";
            }
            $rAliases = implode("|", $rAliases);
            
            if ($moreParams) {
                $rMoreParams = "(\s+.+)";
            }
            else {
                $rMoreParams = "";
            }
            
            if (preg_match("`^ (?: $rAliases ) \s+ (?: \#? ) (\d+)-(\d+)-(\d+) " . $rMoreParams . " $`x", $message, $aMatches)) {
                if ($moreParams) {
                    return $this->$method($aMatches[1], $aMatches[2], $aMatches[3], trim($aMatches[4]));
                }
                else {
                    return $this->$method($aMatches[1], $aMatches[2], $aMatches[3]);
                }
            }
            elseif (preg_match("`^ (?: $rAliases ) \s+ (\d+(?: \s* , \s* \d+)*) " . $rMoreParams . " $`x", $message, $aMatches)) {
                $aIds = preg_split("`\s*,\s*`", $aMatches[1], -1, PREG_SPLIT_NO_EMPTY);
                
                $aResults = array ();
                foreach ($aIds as $id) {
                    list ($listId, $taskseriesId, $taskId) = $this->getTaskFromContext($id);
                    if ($moreParams) {
                        $aResults[] = "$id -- " . $this->$method($listId, $taskseriesId, $taskId, trim($aMatches[2]));
                    }
                    else {
                        $aResults[] = "$id -- " . $this->$method($listId, $taskseriesId, $taskId);
                    }
                }
                
                return implode("\n", $aResults); 
            }
        }
        
        return false;
    }
    
    private function getTaskFromContext($contextId) {
        if (!isset($this->storage["aContextTasks"])) {
            $this->storage["aContextTasks"] = array ();
        }
        
        if (array_key_exists($contextId, $this->storage["aContextTasks"])) {
            return $this->storage["aContextTasks"][$contextId];
        }
        else {
            throw new RtmBotUserError("There is no task with ID $contextId in your current context");
        }
    }
    
    private function clearTaskContext() {
        $this->storage["aContextTasks"] = array ();
    }
    
    /**
     * 
     * @param RtmApiTaskseries[] $aTaskseries
     */
    private function putTasksToContext($aTaskseries) {
        if (!isset($this->storage["aContextTasks"])) {
            $this->storage["aContextTasks"] = array ();
        }
        
        $aNewContext = array ();
        foreach ($aTaskseries as $i => $taskseries) {
            $aNewContext[$i + 1] = array ($taskseries->listId, $taskseries->id, $taskseries->task->id);
        }
        $this->storage["aContextTasks"] = $aNewContext;
    }
    
    private function updateInfoForTaskInContext($listId, $taskseriesId, $taskId, RtmApiTaskseria $taskseries) {
        if (!isset($this->storage["aContextTasks"])) {
            $this->storage["aContextTasks"] = array ();
        }
        
        $aContext = $this->storage["aContextTasks"];
        $aNewContext = array ();
        foreach ($aContext as $i => $a) {
            if ($a[0] == $listId && $a[1] == $taskseriesId && $a[2] == $taskId) {
                $aNewContext[$i] = array ($taskseries->listId, $taskseries->id, $taskseries->task->id);
            }
            else {
                $aNewContext[$i] = $a;
            }
        }
        
        $this->storage["aContextTasks"] = $aNewContext;
    }
    
    private function commandCompleteTask($listId, $taskseriesId, $taskId) {
        $this->log("Marking task as completed...");
        $this->api->taskComplete($listId, $taskseriesId, $taskId);
        $this->log("Task marked as completed");
        
        if ($this->fromStorage("comfirmation", true)) {
            return "Task completed";
        }
    }

    private function commandDeleteTask($listId, $taskseriesId, $taskId) {
        $this->log("Deleting task...");
        $this->api->taskDelete($listId, $taskseriesId, $taskId);
        $this->log("Task deleted");
        
        if ($this->fromStorage("comfirmation", true)) {
            return "Task deleted";
        }
    }
    
    private function commandPostponeTask($listId, $taskseriesId, $taskId) {
        $this->log("Postponing task...");
        $this->api->taskPostpone($listId, $taskseriesId, $taskId);
        $this->log("Task postponed");
        
        if ($this->fromStorage("comfirmation", true)) {
            return "Task postponed";
        }
    }
    
    private function commandAddTagsToTask($listId, $taskseriesId, $taskId, $tags) {
        list ($listIdToMove, $tags) = $this->excludeLists($listId, $taskseriesId, $taskId, $tags);
        
        if ($listIdToMove !== null) {
            $this->log("Moving task to new list $listIdToMove...");
            $list = $this->api->taskMoveTo($listId, $taskseriesId, $taskId, $listIdToMove);
            $this->log("Task moved...");
            
            // update context as List ID has changed
            $aTaskseries = $list->getTaskseries();
            $this->updateInfoForTaskInContext($listId, $taskseriesId, $taskId, $aTaskseries[0]);
        }
        
        if ($tags != "") {
            $this->log("Adding tags to task...");
            $this->api->taskAddTags($listId, $taskseriesId, $taskId, $tags);
            $this->log("Tags added");
        }
        else {
            $this->log("No tags to add");
        }
        
        if ($this->fromStorage("comfirmation", true)) {
            return "Tags/List added";
        }
    }
    
    /**
     * Extract List and tags from the list of comma- or space-separated words.
     * 
     * @param $listId
     * @param $taskseriesId
     * @param $taskId
     * @param $tags
     * @return array (listIdToMoveTo, tags)
     */
    private function excludeLists($listId, $taskseriesId, $taskId, $tags) {
        $this->log("Getting lists...");
        $aLists = $this->api->listGetList();
        $this->log("Lists gotten");
        
        $listIdToMove = null;
        
        $aListsLookup = array ();
        foreach ($aLists as $list) {
            if (!$list->smart) {
                $aListsLookup[mb_strtolower($list->name, "utf-8")] = $list;
            }
        }
        
        $aTags = preg_split("`((\s*,\s*)|\s+)`", $tags, -1, PREG_SPLIT_NO_EMPTY);
        foreach ($aTags as $i => $tag) {
            $tag = mb_strtolower($tag, "utf-8");
            if (array_key_exists($tag, $aListsLookup)) {
                if ($listId != $aListsLookup[$tag]->id) {
                    $listIdToMove = $aListsLookup[$tag]->id;
                }
                
                unset($aTags[$i]);
            }
        }
        
        $tags = implode(",", $aTags);
        
        return array ($listIdToMove, $tags);
    }
    
    private function commandRemoveTagsFromTask($listId, $taskseriesId, $taskId, $tags) {
        $this->log("Removing tags from task...");
        $this->api->taskRemoveTags($listId, $taskseriesId, $taskId, $tags);
        $this->log("Tags removed");
        
        if ($this->fromStorage("comfirmation", true)) {
            return "Tags removed";
        }
    }
    
    private function commandList($filter) {
        $this->log("Getting the task list...");
        $aLists = $this->api->taskGetList(null, $filter);
        $this->log("List gotten");
        
        $aList = array ();
        foreach ($aLists as $list) {
            $aTaskseries = $list->getTaskseries();
            $aList = array_merge($aList, $aTaskseries);
        }
        
        usort($aList, array ($this, "compareTaskseries"));
        
        $this->clearTaskContext();
        
        if (!empty($aList)) {
            $this->putTasksToContext($aList);
            
            $resultString = "";
            $i = 1;
            foreach ($aList as $item) {
                $resultString .= "$i. " . $item . "\n\n";
                $i++;
            }
            return $resultString;
        }
        else {
            return "*no tasks*";
        }
    }
    
    private function commandComfirmation() {
        if ($this->fromStorage("comfirmation", true)) {
            $this->storage["comfirmation"] = false;
            return "Task add comfirmation is now OFF";
        }
        else {
            $this->storage["comfirmation"] = true;
            return "Task add comfirmation is now ON";
        }
    }
    
    private function commandAddTask($name, $note) {
        try {
            $this->log("Adding new task...");
            $list = $this->api->taskAdd($name, true);
            $this->log("Task added");
            
            $taskseries = $list->getTaskseries();
            $taskseries = $taskseries[0];
            
            if ($note !== null) {
                $note = trim($note);
                
                if (preg_match("`^([^\n]+)\n(.+)$`s", $note, $aMatches) && !preg_match("`http(s?)://`i", $aMatches[1])) {
                    $title = $aMatches[1];
                    $text = $aMatches[2];
                }
                else {
                    $title = "Note";
                    $text = $note;
                }
                
                $this->log("Adding task note...");
                $this->api->taskNoteAdd($title, $text, $list->id, $taskseries->id, $taskseries->task->id);
                $this->log("Task note added");
            }
        }
        catch (RtmApiException $e) {
            $this->log("RTM API Error #" . $e->getCode() . ": " . $e->getMessage(), 2);
            return "ERROR: " . $e->getMessage();
        }
        
        if ($this->fromStorage("comfirmation", true)) {
            return "Task added: $taskseries";
        }
    }
    
    private function createFilterString($query) {
        if ($query != "") {
            if (preg_match("`:`", $query)) {
                // The query already has filters. Use it as is.
                $filter = $query;
            }
            else {
                // By default search by keywork, List, Tag or Location and only in incomplet tasks
                $filter = "($query OR list:$query OR tag:$query OR location:$query) AND status:incomplete";
            }
            
            if (!preg_match("`status:`", $query)) {
                // return only incomplete tasks if not specified explicitly
                $filter .= " AND status:incomplete";
            }
        }
        else {
            $filter = "status:incomplete";
        }
        
        return $filter;
    }
    
    public function compareTaskseries(RtmApiTaskseria $left, RtmApiTaskseria $right) {
        if ($left->task->due < $right->task->due) {
            return -1;
        }
        elseif ($left->task->due > $right->task->due) {
            return 1;
        }
        else {
            return 0;
        }
    }
    
    private function needAdmin() {
        if ($this->adminJidHash !== null && sha1(strtolower($this->getUser())) != $this->adminJidHash) {
            throw new RtmBotUserError("You need to be admin to do the command");
        }
    }
    
    private function getHelpMessage() {
        return "
:: Jabber2RTM Bot Commands List ::

HELP -- show this help message

LIST [filter] -- show the list of tasks with optional filter, see: http://www.rememberthemilk.com/help/answers/search/advanced.rtm
L [filter] -- LIST command alias
? [filter] -- LIST command alias

COMFIRMATION -- Turn On/Off showing command execution comfirmation messages

:: About Task IDs ::

The commands below expect you to specify task ID. You can get task ID when doing LIST command. Each task has its own ID at the end of the task name. Ex.: #1411939-81818608-120901386
Also you can use the list item number instead of the task ID. You can use several list item numbers in one command, comma-separated.

:: Task Manipulation Commands ::

COMPLETE #taskId -- mark the task as completed. Task id is shown below each task. Ex.: #1411939-81818608-120901386
C #taskId -- COMPLETE command alias
+ #taskId -- COMPLETE command alias

DELETE #taskId -- delete the task
D #taskId -- DELETE command alias
- #taskId -- DELETE command alias

POSTPONE #taskId -- Postpones a task. If the task has no due date or is overdue, its due date is set to today. Otherwise, the task due date is advanced a day.
P #taskId -- POSTPONE command alias
> #taskId -- POSTPONE command alias

TAGS #taskId -- Add tags to the task or move the task to another list.
T #taskId -- TAGS command alias
# #taskId -- TAGS command alias

-TAGS #taskId -- Remove tags from the task.
-T #taskId -- -TAGS command alias
-# #taskId -- -TAGS command alias

:: Adding New Task ::

If your message does not match the commands' formats specified above then it will be used to add a new task to RememberTheMilk.com using SmartAdd formatting method: http://www.rememberthemilk.com/services/smartadd/

Task format:
<task_name>
[<task_note>]

  * <task_name> -- the name of the task and optionally the SmartAdd data (tags, places, etc.)
  * <task_note> -- optional task note. Can be multiline.

:: News & Updates ::

  * Twitter: http://twitter.com/jabber2rtm
  * Juick: http://juick.com/jabber2rtm/

:: Source Code ::

The project is Open Souce so you can find souces (in PHP) here:

  * http://code.google.com/p/jabber2rtm/";
    }
}

class RtmBotUserError extends Exception {}
